import os
import sys
import time
import re
from pathlib import Path
from threading import Event, Thread
from concurrent.futures import ProcessPoolExecutor
from typing import Optional, List, Dict, Tuple, Any
from queue import Queue
import multiprocessing as mp

import fitz  # PyMuPDF
from PIL import Image, TiffImagePlugin

# PySimpleGUI 界面库
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

# 线程安全事件队列
EVENTS_QUEUE: Queue = Queue()


def post_event(key: str, value: object):
    try:
        EVENTS_QUEUE.put((key, value))
    except Exception:
        pass


def popup_error_fallback(title: str, message: str):
    try:
        messagebox.showerror(title, message)
    except Exception:
        pass


def log_exception_to_file():
    import traceback
    try:
        base_dir = Path(sys.executable).parent if getattr(sys, "frozen", False) else Path.cwd()
        err_path = base_dir / "pdf_to_tif_gui_error.log"
        with open(err_path, "a", encoding="utf-8") as f:
            f.write(time.strftime("[%Y-%m-%d %H:%M:%S] ") + traceback.format_exc() + "\n")
    except Exception:
        pass

# ------------------------------ 路径与文件名安全 ------------------------------

def sanitize_filename(name: str) -> str:
    # 替换Windows不允许或容易出问题的字符，兼容全角符号
    name = re.sub(r"[<>:\\/\|?*\x00-\x1f]", "_", name)
    name = name.strip().rstrip(".")
    # 控制长度，避免过长路径
    return name[:180] if len(name) > 180 else name


def win_safe_path(p: Path) -> str:
    s = str(p)
    if os.name == "nt" and len(s) >= 240 and not s.startswith('\\\\?\\'):
        # 非网络路径时前缀长路径支持
        if not s.startswith('\\\\'):
            return '\\\\?\\' + s
    return s

# ------------------------------ 业务函数 ------------------------------



def ensure_dir(path: Path):
    path.mkdir(parents=True, exist_ok=True)


def localize_error_text(msg: str) -> str:
    try:
        # 已有中文内容则直接返回
        if re.search(r"[\u4e00-\u9fff]", msg or ""):
            return msg
        m = (msg or "").lower()
        mapping = [
            ("permission denied", "权限不足（无法读/写）"),
            ("no such file", "路径不存在或文件已删除"),
            ("cannot open", "无法打开文件"),
            ("cannot write", "无法写入文件"),
            ("i/o error", "读写错误（磁盘或文件占用）"),
            ("out of memory", "内存不足"),
            ("too many open files", "打开文件过多（系统限制）"),
            ("invalid", "文件格式不合法或损坏"),
            ("unsupported", "不支持的文件或操作"),
            ("identify image", "图像解码失败"),
            ("decoder", "图像解码失败"),
            ("tiff", "TIFF保存失败"),
            ("compression", "压缩处理失败"),
            ("document closed or encrypted", "文档已关闭或加密（需密码）"),
            ("document closed", "文档已关闭"),
            ("encrypted", "文档已加密（需密码）"),
        ]
        for key, cn in mapping:
            if key in m:
                return f"{cn}（{msg}）"
        return f"错误：{msg}"
    except Exception:
        return msg


def save_page_as_tif_retry(doc: fitz.Document, page_index: int, out_dir: Path, dpi: int, overwrite: bool, compression: str = "lzw", max_retries: int = 1, unit: int = 2, adaptive_size: bool = True, size_mode: str = "longest", target_long_side: int = 0, target_width: int = 0, target_height: int = 0):
    # 文件命名：从 0001 开始（按页码一基），按总页数计算补零位数，至少 4 位
    try:
        total = int(getattr(doc, "page_count", 0) or 0)
    except Exception:
        total = 0
    pad_width = max(4, len(str(max(total, 1))))
    out_name = str(page_index + 1).zfill(pad_width) + ".tif"
    out_path = out_dir / out_name
    if out_path.exists() and not overwrite:
        return True

    # 压缩方式映射
    comp = (compression or "lzw").lower()
    comp_param: Optional[str] = None
    if comp == "lzw":
        comp_param = "tiff_lzw"
    elif comp in ("deflate", "adobe_deflate"):
        comp_param = "tiff_adobe_deflate"
    elif comp == "packbits":
        comp_param = "packbits"
    elif comp == "none":
        comp_param = None

    # 分辨率单位
    try:
        unit_val = int(unit or 2)
        if unit_val not in (1, 2, 3):
            unit_val = 2
    except Exception:
        unit_val = 2

    last_err: Optional[Exception] = None
    for attempt in range(max_retries + 1):
        try:
            page = doc.load_page(page_index)
            mat = fitz.Matrix(dpi / 72.0, dpi / 72.0)
            pix = page.get_pixmap(matrix=mat, alpha=False)
            img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)

            # 尺寸调整（非自适应时按模式缩放，保持比例）
            try:
                if not bool(adaptive_size):
                    resample_cls = getattr(Image, "Resampling", None)
                    resample_method = getattr(resample_cls, "LANCZOS", None) if resample_cls else getattr(Image, "LANCZOS", Image.BICUBIC)
                    new_w, new_h = pix.width, pix.height
                    if (str(size_mode) or "longest") == "longest" and int(target_long_side or 0) > 0:
                        long_edge = max(pix.width, pix.height)
                        ratio = max(1e-9, float(target_long_side) / float(long_edge))
                        new_w = max(1, int(round(pix.width * ratio)))
                        new_h = max(1, int(round(pix.height * ratio)))
                    elif (str(size_mode) or "longest") == "wh" and int(target_width or 0) > 0 and int(target_height or 0) > 0:
                        ratio = min(float(target_width) / float(pix.width), float(target_height) / float(pix.height))
                        ratio = max(1e-9, ratio)
                        new_w = max(1, int(round(pix.width * ratio)))
                        new_h = max(1, int(round(pix.height * ratio)))
                    if (new_w, new_h) != (pix.width, pix.height):
                        img = img.resize((new_w, new_h), resample=resample_method)
            except Exception:
                pass

            # 显式使用文件句柄写入，避免某些情况下的句柄问题
            out_str = win_safe_path(out_path)
            # 设置 TIFF 分辨率标签
            try:
                ifd = TiffImagePlugin.ImageFileDirectory_v2()
                ifd[296] = unit_val  # ResolutionUnit: 1=None, 2=inch, 3=cm
            except Exception:
                ifd = None

            # DPI元数据：若为厘米单位，需换算为DPCM
            try:
                if unit_val == 3:
                    dpi_tuple = (dpi / 2.54, dpi / 2.54)
                else:
                    dpi_tuple = (dpi, dpi)
            except Exception:
                dpi_tuple = (dpi, dpi)

            with open(out_str, "wb") as f:
                if attempt == 0:
                    if comp_param:
                        if ifd is not None:
                            img.save(f, format="TIFF", compression=comp_param, dpi=dpi_tuple, tiffinfo=ifd)
                        else:
                            img.save(f, format="TIFF", compression=comp_param, dpi=dpi_tuple)
                    else:
                        if ifd is not None:
                            img.save(f, format="TIFF", dpi=dpi_tuple, tiffinfo=ifd)
                        else:
                            img.save(f, format="TIFF", dpi=dpi_tuple)
                else:
                    # 回退为不压缩，尽量保证保存成功
                    if ifd is not None:
                        img.save(f, format="TIFF", dpi=dpi_tuple, tiffinfo=ifd)
                    else:
                        img.save(f, format="TIFF", dpi=dpi_tuple)
            return True
        except Exception as e:
            last_err = e
            time.sleep(0.2)
    # 重试失败后抛出最后一次错误
    if last_err is not None:
        raise last_err
    return False


def collect_pdfs_from_dir(input_dir: Path):
    return sorted([p for p in input_dir.glob("*.pdf") if p.is_file()])

# ------------------------------ 多进程工作函数 ------------------------------

def _calc_page_sleep_proc(dpi: int) -> float:
    try:
        if dpi >= 500:
            return 0.004
        if dpi >= 400:
            return 0.003
        if dpi >= 300:
            return 0.002
        if dpi >= 200:
            return 0.001
        return 0.0
    except Exception:
        return 0.0


def convert_pdf_worker(pdf_path: str, out_root: str, dpi: int, overwrite: bool, compression: str, unit: int, size_auto: bool, size_mode: str, long_side: int, width: int, height: int, q, stop_flag):
    name = sanitize_filename(Path(pdf_path).stem)
    out_dir = Path(out_root) / name
    ensure_dir(out_dir)
    # 若选择覆盖，则预先清理旧的 TIFF 文件，避免遗留页
    try:
        if overwrite:
            for p in out_dir.glob("*.tif"):
                try:
                    p.unlink()
                except Exception:
                    pass
    except Exception:
        pass
    cur = 0
    aborted = False
    try:
        # 降低进程优先级，避免抢占过多资源
        try:
            if os.name == "nt":
                import ctypes, os as _os
                PROCESS_SET_INFORMATION = 0x0200
                PROCESS_QUERY_INFORMATION = 0x0400
                BELOW_NORMAL_PRIORITY_CLASS = 0x00004000
                handle = ctypes.windll.kernel32.OpenProcess(
                    PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, False, _os.getpid()
                )
                if handle:
                    ctypes.windll.kernel32.SetPriorityClass(handle, BELOW_NORMAL_PRIORITY_CLASS)
                    ctypes.windll.kernel32.CloseHandle(handle)
        except Exception:
            pass

        with fitz.open(pdf_path) as doc:
            total = doc.page_count
            try:
                q.put(("FILE_INIT", {"id": str(pdf_path), "name": f"{name}.pdf", "total": total}))
            except Exception:
                pass
            for page_index in range(total):
                if stop_flag is not None and bool(stop_flag.value):
                    aborted = True
                    try:
                        q.put(("FILE_ABORT", {"id": str(pdf_path)}))
                    except Exception:
                        pass
                    break
                try:
                    ok = save_page_as_tif_retry(
                        doc,
                        page_index,
                        out_dir,
                        dpi,
                        overwrite,
                        compression=compression,
                        unit=unit,
                        adaptive_size=size_auto,
                        size_mode=size_mode,
                        target_long_side=long_side,
                        target_width=width,
                        target_height=height,
                    )
                    if ok:
                        cur += 1
                        try:
                            q.put(("PROGRESS", 1))
                            q.put(("FILE_PAGE", {"id": str(pdf_path), "cur": cur, "total": total}))
                        except Exception:
                            pass
                        s = _calc_page_sleep_proc(dpi)
                        if s > 0:
                            time.sleep(s)
                    else:
                        try:
                            q.put(("FILE_ERROR", {"id": str(pdf_path), "page": page_index + 1, "error": "未知失败"}))
                        except Exception:
                            pass
                except Exception as e:
                    try:
                        q.put(("FILE_ERROR", {"id": str(pdf_path), "page": page_index + 1, "error": localize_error_text(str(e))}))
                    except Exception:
                        pass
    except Exception as e:
        # 打开失败等重大错误，不打印LOG，仅标记为错误
        try:
            q.put(("FILE_ERROR", {"id": str(pdf_path), "page": 0, "error": localize_error_text(str(e))}))
        except Exception:
            pass
    finally:
        try:
            if not aborted:
                q.put(("FILE_DONE", {"id": str(pdf_path)}))
        except Exception:
            pass

# ------------------------------ Tkinter GUI ------------------------------

class PdfToTifApp:
    def __init__(self, root: tk.Tk):
        self.root = root
        self.root.title("PDF 转 TIFF 批量转换")
        # 限制最小窗口尺寸，避免布局挤压
        self.root.minsize(900, 600)
        # 绑定关闭确认
        try:
            self.root.protocol("WM_DELETE_WINDOW", self._on_window_close)
        except Exception:
            pass

        # 变量
        self.input_mode_var = tk.StringVar(value="dir")  # dir 或 file
        self.input_path_var = tk.StringVar(value=str(Path("D:/Desktop/批量处理").resolve()))
        self.output_dir_var = tk.StringVar(value=str(Path("D:/Desktop/批量处理/输出").resolve()))
        self.dpi_var = tk.IntVar(value=300)
        self.overwrite_var = tk.BooleanVar(value=False)
        self.parallel_var = tk.BooleanVar(value=False)
        self.compression_var = tk.StringVar(value="lzw")
        # 新增：分辨率单位与尺寸选项
        self.unit_var = tk.IntVar(value=2)            # 1=无单位, 2=英寸(DPI), 3=厘米(DPCM)
        self.unit_str_var = tk.StringVar(value="2")
        self.res_label_var = tk.StringVar(value="")
        self.size_auto_var = tk.BooleanVar(value=True) # 自适应尺寸（按PDF原始比例渲染）
        self.size_mode_var = tk.StringVar(value="longest")  # longest 或 wh
        self.long_side_var = tk.IntVar(value=0)        # 目标最长边（px）
        self.width_var = tk.IntVar(value=0)            # 目标宽（px）
        self.height_var = tk.IntVar(value=0)           # 目标高（px）
        cpu = os.cpu_count() or 4
        self.workers_var = tk.IntVar(value=2)
        self.max_workers = cpu

        self.total_files = 0
        self.files_done = 0
        self.start_time = 0.0
        self.list_frame = None
        self.total_pages = 0
        self.progressed = 0
        self.running = False
        self._pending_progress = 0
        self._progress_apply_limit = 8
        self._poll_tick = 0
        self._disable_right_list = True
        self._file_names: Dict[str, str] = {}
        self._file_totals: Dict[str, int] = {}
        self._counted_file_ids = set()
        self._active_files = {}
        self._slots: List[Optional[str]] = []
        self._last_status_text = ""
        self.aborted_files = set()
        # 加载统计
        self._load_total = 0
        self._load_success = 0
        self._load_failures = []  # [(name, reason)]
        self._load_logged = False

        self.stop_event = Event()

        # 读取/保存设置路径（AppData）
        try:
            appdata = os.environ.get("APPDATA") or str(Path.home())
            cfg_dir = Path(appdata) / "pdf_to_tif_gui"
            cfg_dir.mkdir(parents=True, exist_ok=True)
            self._settings_path = cfg_dir / "settings.json"
        except Exception:
            self._settings_path = Path("settings.json")
        self.settings = self._load_settings()

        # 文件行管理，按状态分组
        self.file_rows: Dict[str, Dict[str, Any]] = {}
        self.failed_pages: Dict[str, List[Tuple[int, str]]] = {}

        # 右侧三栏容器
        self._build_ui()
        self._schedule_poll_events()

    # ---------- UI 构建 ----------
    def _build_ui(self):
        pad = {"padx": 6, "pady": 6}
        # 顶层滚动容器：当窗口内容溢出时，显示滚动条
        self._root_container = ttk.Frame(self.root)
        self._root_container.pack(side="top", fill="both", expand=True)
        self._root_canvas = tk.Canvas(self._root_container, borderwidth=0, highlightthickness=0)
        self._root_vsb = ttk.Scrollbar(self._root_container, orient="vertical", command=self._root_canvas.yview)
        self._root_hsb = ttk.Scrollbar(self._root_container, orient="horizontal", command=self._root_canvas.xview)
        self._root_canvas.configure(yscrollcommand=self._root_vsb.set, xscrollcommand=self._root_hsb.set)
        self._root_canvas.pack(side="left", fill="both", expand=True)
        self._root_vsb.pack(side="right", fill="y")
        self._root_hsb.pack(side="bottom", fill="x")
        self._root_inner = ttk.Frame(self._root_canvas)
        _win_root = self._root_canvas.create_window((0,0), window=self._root_inner, anchor="nw")
        def _root_on_configure(event=None):
            try:
                self._root_canvas.configure(scrollregion=self._root_canvas.bbox("all"))
                # 竖向滚动条始终显示，避免底部内容被遮挡
                try:
                    self._root_vsb.pack(side="right", fill="y")
                except Exception:
                    pass
                need_h = self._root_inner.winfo_reqwidth() > self._root_canvas.winfo_width()
                if need_h:
                    self._root_hsb.pack(side="bottom", fill="x")
                else:
                    self._root_hsb.pack_forget()
            except Exception:
                pass
        self._root_inner.bind("<Configure>", _root_on_configure)
        self._root_canvas.bind("<Configure>", lambda e: self._root_canvas.itemconfig(_win_root, width=e.width))

        paned = ttk.Panedwindow(self._root_inner, orient="horizontal")
        paned.pack(fill="both", expand=True)

        # 左侧：参数设置
        left = ttk.Frame(paned)
        paned.add(left, weight=1)

        # 输入路径与类型
        row1 = ttk.LabelFrame(left, text="输入")
        row1.pack(fill="x", **pad)
        ttk.Label(row1, text="输入类型").grid(row=0, column=0, sticky="w")
        self.rb_dir = ttk.Radiobutton(row1, text="文件夹", variable=self.input_mode_var, value="dir")
        self.rb_dir.grid(row=0, column=1, sticky="w")
        self.rb_file = ttk.Radiobutton(row1, text="文件", variable=self.input_mode_var, value="file")
        self.rb_file.grid(row=0, column=2, sticky="w")
        ttk.Label(row1, text="路径").grid(row=1, column=0, sticky="w")
        self.in_entry = ttk.Entry(row1, textvariable=self.input_path_var, width=48)
        self.in_entry.grid(row=1, column=1, columnspan=2, sticky="we", padx=(6, 6))
        self.btn_browse_dir = ttk.Button(row1, text="选择文件夹", command=self._browse_input_dir)
        self.btn_browse_dir.grid(row=2, column=1, sticky="w")
        self.btn_browse_file = ttk.Button(row1, text="选择文件", command=self._browse_input_file)
        self.btn_browse_file.grid(row=2, column=2, sticky="w")

        # 根据输入类型启用对应的浏览按钮
        def _apply_input_mode_ui():
            try:
                mode = str(self.input_mode_var.get())
                if mode == "dir":
                    self.btn_browse_dir.state(["!disabled"])  # 仅启用“选择文件夹”
                    self.btn_browse_file.state(["disabled"])   # 禁用“选择文件”
                else:
                    self.btn_browse_dir.state(["disabled"])    # 禁用“选择文件夹”
                    self.btn_browse_file.state(["!disabled"])  # 仅启用“选择文件”
            except Exception:
                pass
        _apply_input_mode_ui()
        try:
            self.rb_dir.configure(command=_apply_input_mode_ui)
            self.rb_file.configure(command=_apply_input_mode_ui)
        except Exception:
            pass

        ttk.Label(row1, text="可选择整个文件夹批量转换或单个PDF文件。", foreground="gray").grid(row=3, column=0, columnspan=3, sticky="w")

        # 输出目录
        row2 = ttk.LabelFrame(left, text="输出")
        row2.pack(fill="x", **pad)
        ttk.Label(row2, text="输出目录").grid(row=0, column=0, sticky="w")
        self.out_entry = ttk.Entry(row2, textvariable=self.output_dir_var, width=48)
        self.out_entry.grid(row=0, column=1, sticky="we", padx=(6, 6))
        self.btn_browse_output = ttk.Button(row2, text="选择", command=self._browse_output)
        self.btn_browse_output.grid(row=0, column=2, sticky="w")
        ttk.Label(row2, text="每个PDF将输出到以其文件名命名的子目录。", foreground="gray").grid(row=1, column=0, columnspan=3, sticky="w")

        # 选项
        row3 = ttk.LabelFrame(left, text="选项")
        row3.pack(fill="x", **pad)

        # 并行与并行数（同一行）
        ttk.Label(row3, text="最大并行数").grid(row=0, column=0, sticky="w")
        self.spin_workers = ttk.Spinbox(row3, from_=1, to=20, textvariable=self.workers_var, width=4)
        self.spin_workers.grid(row=0, column=1, sticky="w")
        self.chk_parallel = ttk.Checkbutton(row3, text="启用并行", variable=self.parallel_var)
        self.chk_parallel.grid(row=0, column=2, sticky="w", padx=(6,0))

        # 并行开关逻辑
        def _apply_parallel_ui():
            try:
                if not bool(self.parallel_var.get()):
                    try:
                        self.workers_var.set(1)
                    except Exception:
                        pass
                    self.spin_workers.state(["disabled"])
                else:
                    self.spin_workers.state(["!disabled"])
            except Exception:
                pass
        _apply_parallel_ui()
        self.chk_parallel.configure(command=_apply_parallel_ui)

        # 分辨率与分辨率单位（同一行）
        ttk.Label(row3, text="分辨率(DPI)").grid(row=2, column=0, sticky="w")
        self.spin_dpi = ttk.Spinbox(row3, from_=100, to=600, increment=50, textvariable=self.dpi_var, width=6)
        self.spin_dpi.grid(row=2, column=1, sticky="w")
        ttk.Label(row3, text="分辨率单位").grid(row=2, column=2, sticky="w")
        self.combo_unit = ttk.Combobox(row3, textvariable=self.unit_str_var, values=["1", "2", "3"], state="readonly", width=6)
        self.combo_unit.grid(row=2, column=3, sticky="w")
        self.combo_unit.set(self.unit_str_var.get())
        def _on_unit_change(*args):
            try:
                num = int(self.unit_str_var.get() or "2")
                if num not in (1, 2, 3):
                    num = 2
                self.unit_var.set(num)
                # 更新分辨率显示（已去除信息展示，仅保持内部状态）
                self._update_res_label()
            except Exception:
                pass
        try:
            self.combo_unit.bind("<<ComboboxSelected>>", lambda e: _on_unit_change())
        except Exception:
            pass
        _on_unit_change()
        ttk.Label(row3, text="编码含义：1 无单位；2 英寸 (DPI)；3 厘米 (DPCM)", foreground="gray").grid(row=4, column=0, columnspan=9, sticky="w", pady=(0,6))

        # 覆盖已存在
        self.chk_over = ttk.Checkbutton(row3, text="覆盖已存在", variable=self.overwrite_var)
        self.chk_over.grid(row=5, column=0, sticky="w")

        # 压缩
        ttk.Label(row3, text="压缩").grid(row=7, column=0, sticky="w")
        self.combo_comp = ttk.Combobox(row3, textvariable=self.compression_var, values=["lzw", "deflate", "packbits", "none"], state="readonly", width=10)
        self.combo_comp.grid(row=7, column=1, sticky="w")
        self.combo_comp.set(self.compression_var.get())
        ttk.Label(row3, text="LZW 均衡；Deflate 更小；PackBits 兼容；None 不压缩最快。", foreground="gray").grid(row=8, column=0, columnspan=9, sticky="w", pady=(0,6))

        # 尺寸选项
        ttk.Label(row3, text="尺寸").grid(row=9, column=0, sticky="w")
        self.chk_size_auto = ttk.Checkbutton(row3, text="自适应尺寸", variable=self.size_auto_var, command=self._apply_size_ui)
        self.chk_size_auto.grid(row=9, column=1, sticky="w")

        self.rb_size_longest = ttk.Radiobutton(row3, text="最长边", variable=self.size_mode_var, value="longest", command=self._apply_size_ui)
        self.rb_size_longest.grid(row=11, column=0, sticky="w")
        self.spin_long_side = ttk.Spinbox(row3, from_=0, to=20000, increment=50, textvariable=self.long_side_var, width=8)
        self.spin_long_side.grid(row=11, column=1, sticky="w")
        ttk.Label(row3, text="px").grid(row=11, column=2, sticky="w")

        self.rb_size_wh = ttk.Radiobutton(row3, text="宽高", variable=self.size_mode_var, value="wh", command=self._apply_size_ui)
        self.rb_size_wh.grid(row=11, column=3, sticky="w")
        self.spin_width = ttk.Spinbox(row3, from_=0, to=20000, increment=50, textvariable=self.width_var, width=8)
        self.spin_width.grid(row=11, column=4, sticky="w")
        ttk.Label(row3, text="×").grid(row=11, column=5, sticky="w")
        self.spin_height = ttk.Spinbox(row3, from_=0, to=20000, increment=50, textvariable=self.height_var, width=8)
        self.spin_height.grid(row=11, column=6, sticky="w")
        ttk.Label(row3, text="px").grid(row=11, column=7, sticky="w")

        # 统一选项间距
        try:
            for r in (0, 2, 5, 7, 9, 11):
                row3.grid_rowconfigure(r, pad=6)
            # 说明行轻微间距
            for r in (4, 8):
                row3.grid_rowconfigure(r, pad=4)
        except Exception:
            pass


        # 初始化尺寸控件状态
        try:
            self._apply_size_ui()
        except Exception:
            pass

        # 控制
        row_ctrl = ttk.LabelFrame(left, text="控制")
        row_ctrl.pack(fill="x", **pad)
        btns = ttk.Frame(row_ctrl)
        btns.pack(fill="x")
        self.btn_start = ttk.Button(btns, text="开始", command=self._on_start)
        self.btn_start.pack(side="left")
        self.btn_open_output = ttk.Button(btns, text="打开输出目录", command=self._open_output)
        self.btn_open_output.pack(side="left", padx=(6,0))

        # 文件进度与时间（置于顶部）
        self.lbl_file_prog = ttk.Label(row_ctrl, text="文件进度：0/0")
        self.lbl_file_prog.pack(anchor="w")
        self.lbl_time = ttk.Label(row_ctrl, text="用时 00:00 | 预计剩余 00:00", foreground="gray")
        self.lbl_time.pack(anchor="w")

        self.pb = ttk.Progressbar(row_ctrl, orient="horizontal", mode="determinate", length=260)
        self.pb.pack(fill="x", padx=0, pady=(6,0))
        # 并发状态显示列表：固定高度为20行，超出内容使用滚动条
        status_area = ttk.Frame(row_ctrl)
        status_area.pack(fill="both", expand=True)
        self.status_text = tk.Text(status_area, height=20, wrap="none", state="disabled")
        status_scroll = ttk.Scrollbar(status_area, orient="vertical", command=self.status_text.yview)
        self.status_text.configure(yscrollcommand=status_scroll.set)
        self.status_text.pack(side="left", fill="both", expand=True)
        status_scroll.pack(side="right", fill="y")
        self.status_text.tag_configure("idle", foreground="yellow")

        # 右侧：Notebook（文件列表、日志）
        right = ttk.Frame(paned)
        paned.add(right, weight=2)
        nb = ttk.Notebook(right)
        nb.pack(fill="both", expand=True, padx=6, pady=6)

        # 文件列表（可禁用）
        if not getattr(self, "_disable_right_list", False):
            self.list_frame = self._create_scrolled_frame(nb, "文件列表")
        else:
            self.list_frame = None

        # 日志（右侧标签，缩窄宽度并补边框）
        log_tab = ttk.Frame(nb)
        nb.add(log_tab, text="日志")
        log_box = ttk.Frame(log_tab, borderwidth=1, relief="solid")
        log_box.pack(side="top", fill="y", expand=False, padx=6, pady=6)
        self.log_text = tk.Text(log_box, width=60, wrap="word", state="disabled")
        log_scroll = ttk.Scrollbar(log_box, orient="vertical", command=self.log_text.yview)
        self.log_text.configure(yscrollcommand=log_scroll.set)
        self.log_text.pack(side="left", fill="y", expand=False, padx=6, pady=6)
        log_scroll.pack(side="right", fill="y", padx=(0,6), pady=6)
        self.log_text.tag_configure("info", foreground="black")
        self.log_text.tag_configure("warn", foreground="orange")
        self.log_text.tag_configure("error", foreground="red")
        self.log_text.tag_configure("stats", foreground="blue")
        self.log_text.tag_configure("ok", foreground="green")
        self._log_max_lines = 500

        try:
            footer = ttk.Frame(self.root)
            footer.pack(side="bottom", fill="x")
            center = ttk.Frame(footer)
            center.pack(pady=(2,4))  # 居中显示，无填充，仅占内容宽度
            year = time.localtime().tm_year
            lbl_copy = tk.Label(center, text=f"© {year} 杰", fg="blue", cursor="hand2")
            lbl_copy.pack(side="left", padx=(6,0))
            def _open_link(event=None):
                try:
                    import webbrowser
                    webbrowser.open("https://gitee.com/wan-jie7500")
                except Exception:
                    pass
            lbl_copy.bind("<Button-1>", _open_link)
        except Exception:
            pass

    def _create_scrolled_frame(self, notebook: ttk.Notebook, title: str):
        container = ttk.Frame(notebook)
        canvas = tk.Canvas(container, borderwidth=0)
        vsb = ttk.Scrollbar(container, orient="vertical", command=canvas.yview)
        inner = ttk.Frame(canvas)
        inner.bind(
            "<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
        )
        win = canvas.create_window((0, 0), window=inner, anchor="nw")
        # 绑定宽度，使内部区域始终与画布等宽，统一进度条长度
        canvas.bind("<Configure>", lambda e: canvas.itemconfig(win, width=e.width))
        canvas.configure(yscrollcommand=vsb.set)
        canvas.pack(side="left", fill="both", expand=True)
        vsb.pack(side="right", fill="y")
        notebook.add(container, text=title)
        return inner

    # ---------- 辅助 UI 操作 ----------
    def _set_controls_running(self, running: bool):
        try:
            dis = ["disabled"]
            en = ["!disabled"]
            for w in (
                getattr(self, "rb_dir", None),
                getattr(self, "rb_file", None),
                getattr(self, "in_entry", None),
                getattr(self, "btn_browse_dir", None),
                getattr(self, "btn_browse_file", None),
                getattr(self, "out_entry", None),
                getattr(self, "btn_browse_output", None),
                getattr(self, "spin_dpi", None),
                getattr(self, "chk_over", None),
                getattr(self, "spin_workers", None),
                getattr(self, "combo_comp", None),
                # 新增：并行控件在运行时禁用
                getattr(self, "chk_parallel", None),
                # 新增：单位与尺寸控件在运行时禁用
                getattr(self, "combo_unit", None),
                getattr(self, "chk_size_auto", None),
                getattr(self, "rb_size_longest", None),
                getattr(self, "rb_size_wh", None),
                getattr(self, "spin_long_side", None),
                getattr(self, "spin_width", None),
                getattr(self, "spin_height", None),
            ):
                if w:
                    w.state(dis if running else en)
            # 打开输出目录在运行期间保持可用
            if getattr(self, "btn_open_output", None):
                self.btn_open_output.state(en)
            # 统一运行态主按钮行为：开始⇄停止
            if getattr(self, "btn_start", None):
                try:
                    if running:
                        self.btn_start.configure(text="停止", command=self._on_stop)
                        self.btn_start.state(["!disabled"])  # 运行时可点击停止
                    else:
                        self.btn_start.configure(text="开始", command=self._on_start)
                        self.btn_start.state(["!disabled"])  # 空闲时可点击开始
                except Exception:
                    pass

            # 非运行态时，重新应用输入模式与并行控件状态，避免无关控件被误启用
            try:
                if not running:
                    # 根据输入类型仅启用对应浏览按钮
                    mode = str(self.input_mode_var.get())
                    if mode == "dir":
                        if getattr(self, "btn_browse_dir", None):
                            self.btn_browse_dir.state(["!disabled"]) 
                        if getattr(self, "btn_browse_file", None):
                            self.btn_browse_file.state(["disabled"])   
                    else:
                        if getattr(self, "btn_browse_dir", None):
                            self.btn_browse_dir.state(["disabled"])    
                        if getattr(self, "btn_browse_file", None):
                            self.btn_browse_file.state(["!disabled"])  
            except Exception:
                pass
            try:
                if not running:
                    # 根据并行开关状态控制并行数量输入框
                    if not bool(self.parallel_var.get()):
                        try:
                            self.workers_var.set(1)
                        except Exception:
                            pass
                        if getattr(self, "spin_workers", None):
                            self.spin_workers.state(["disabled"]) 
                    else:
                        if getattr(self, "spin_workers", None):
                            self.spin_workers.state(["!disabled"]) 
            except Exception:
                pass

            # 非运行态时，按当前勾选与模式应用尺寸控件状态
            try:
                if not running:
                    self._apply_size_ui()
            except Exception:
                pass

            # 独立“停止”按钮已删除
        except Exception:
            pass

    def _apply_size_ui(self):
        try:
            auto = bool(self.size_auto_var.get())
            # 统一禁用/启用控件
            controls = [
                getattr(self, "rb_size_longest", None),
                getattr(self, "rb_size_wh", None),
                getattr(self, "spin_long_side", None),
                getattr(self, "spin_width", None),
                getattr(self, "spin_height", None),
            ]
            if auto:
                for w in controls:
                    if w:
                        w.state(["disabled"])
            else:
                # 单选按钮可用
                for w in (getattr(self, "rb_size_longest", None), getattr(self, "rb_size_wh", None)):
                    if w:
                        w.state(["!disabled"]) 
                # 根据模式启用对应输入框
                mode = str(self.size_mode_var.get() or "longest")
                if mode == "longest":
                    if getattr(self, "spin_long_side", None):
                        self.spin_long_side.state(["!disabled"]) 
                    if getattr(self, "spin_width", None):
                        self.spin_width.state(["disabled"]) 
                    if getattr(self, "spin_height", None):
                        self.spin_height.state(["disabled"]) 
                else:
                    if getattr(self, "spin_long_side", None):
                        self.spin_long_side.state(["disabled"]) 
                    if getattr(self, "spin_width", None):
                        self.spin_width.state(["!disabled"]) 
                    if getattr(self, "spin_height", None):
                        self.spin_height.state(["!disabled"]) 
        except Exception:
            pass

    def _update_res_label(self):
        try:
            unit = int(self.unit_var.get() or 2)
        except Exception:
            unit = 2
        self.res_label_var.set(f"单位编码：{unit}")

    def _move_row(self, file_id: str, new_state: str):
        if getattr(self, "_disable_right_list", False):
            return
        fr = self.file_rows.get(file_id)
        if not fr:
            return
        try:
            frame = fr["frame"]
            frame.pack_forget()
            children = self.list_frame.winfo_children() if hasattr(self, "list_frame") and self.list_frame else []
            if new_state == "inprog":
                if children:
                    frame.pack(fill="x", padx=6, pady=4, before=children[0])
                else:
                    frame.pack(fill="x", padx=6, pady=4)
            elif new_state == "failed":
                frame.pack(fill="x", padx=6, pady=4)
                if fr.get("retry_btn") is None:
                    fr["retry_btn"] = ttk.Button(frame, text="重试失败页", command=lambda: self._retry_failed(file_id))
                    fr["retry_btn"].pack(anchor="w", pady=(2, 0))
                    try:
                        fr["retry_btn"].state(["disabled"] if self.running else ["!disabled"])
                    except Exception:
                        pass
            elif new_state == "done":
                try:
                    frame.destroy()
                except Exception:
                    pass
                self.file_rows.pop(file_id, None)
                return
            fr["state"] = new_state
            if new_state != "failed" and fr.get("retry_btn") is not None:
                try:
                    fr["retry_btn"].destroy()
                except Exception:
                    pass
                fr["retry_btn"] = None
        except Exception:
            pass

    # ---------- 浏览按钮 ----------
    def _browse_input_dir(self):
        d = filedialog.askdirectory(initialdir=self.input_path_var.get() or "/")
        if d:
            self.input_mode_var.set("dir")
            self.input_path_var.set(d)

    def _browse_input_file(self):
        f = filedialog.askopenfilename(initialdir=self.input_path_var.get() or "/", filetypes=[("PDF 文件", "*.pdf"), ("所有文件", "*.*")])
        if f:
            self.input_mode_var.set("file")
            self.input_path_var.set(f)

    def _browse_output(self):
        d = filedialog.askdirectory(initialdir=self.output_dir_var.get() or "/")
        if d:
            self.output_dir_var.set(d)

    # ---------- 日志 ----------
    def _log_print(self, text: str, level: str = "info"):
        try:
            if not getattr(self, "log_text", None):
                return
            self.log_text.configure(state="normal")
            self.log_text.insert("end", (text or "") + "\n", level)
            # 限制最大行数以优化内存
            try:
                lines = int(self.log_text.index("end-1c").split(".")[0])
            except Exception:
                lines = 0
            max_lines = int(getattr(self, "_log_max_lines", 500) or 500)
            if lines > max_lines:
                self.log_text.delete("1.0", f"{lines - max_lines}.0")
            self.log_text.see("end")
            self.log_text.configure(state="disabled")
        except Exception:
            pass

    # ---------- 设置与提醒 ----------
    def _load_settings(self) -> Dict[str, Any]:
        try:
            p = getattr(self, "_settings_path", None)
            if p and Path(p).exists():
                import json
                with open(p, "r", encoding="utf-8") as f:
                    return json.load(f) or {}
        except Exception:
            pass
        return {}

    def _save_settings(self):
        try:
            p = getattr(self, "_settings_path", None)
            if not p:
                return
            import json
            with open(p, "w", encoding="utf-8") as f:
                json.dump(getattr(self, "settings", {}) or {}, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _ask_start_confirmation(self) -> Tuple[bool, bool]:
        dlg = tk.Toplevel(self.root)
        dlg.title("提醒")
        try:
            dlg.transient(self.root)
            dlg.grab_set()
        except Exception:
            pass
        try:
            dlg.resizable(False, False)
        except Exception:
            pass
        frm = ttk.Frame(dlg, padding=12)
        frm.pack(fill="both", expand=True)
        msg = (
            "转换过程中会大量占用CPU、磁盘与内存资源。\n"
            "请避免同时进行大规模复制、系统睡眠/休眠或其他重负载操作。\n"
            "是否继续？"
        )
        ttk.Label(frm, text=msg, justify="left").pack(anchor="w")
        dont_var = tk.BooleanVar(value=False)
        ttk.Checkbutton(frm, text="不再提醒", variable=dont_var).pack(anchor="w", pady=(8,0))
        btns = ttk.Frame(frm)
        btns.pack(fill="x", pady=(10,0))
        result = {"ok": False}
        def on_ok():
            result["ok"] = True
            dlg.destroy()
        def on_cancel():
            result["ok"] = False
            dlg.destroy()
        ttk.Button(btns, text="继续", command=on_ok).pack(side="left", padx=(0,6))
        ttk.Button(btns, text="取消", command=on_cancel).pack(side="left")
        try:
            dlg.update_idletasks()
            x = self.root.winfo_rootx() + (self.root.winfo_width() - dlg.winfo_reqwidth()) // 2
            y = self.root.winfo_rooty() + (self.root.winfo_height() - dlg.winfo_reqheight()) // 2
            dlg.geometry(f"+{max(x,0)}+{max(y,0)}")
        except Exception:
            pass
        try:
            self.root.wait_window(dlg)
        except Exception:
            pass
        return bool(result.get("ok", False)), bool(dont_var.get())

    # ---------- 事件轮询 ----------
    def _schedule_poll_events(self):
        try:
            progress_acc = 0
            updated_files = set()
            file_inits = []
            file_errors = []
            file_dones = []
            file_aborts = []
            retry_updates = []
            retry_dones = []
            finish_flag = False
            load_update = None
            load_done = False
            # 新增：日志与加载结果队列
            log_msgs = []
            load_fails = []
            load_oks = []

            # 每次最多处理一定数量事件，避免主线程长时间阻塞
            max_evts = 200
            while max_evts > 0 and not EVENTS_QUEUE.empty():
                key, value = EVENTS_QUEUE.get()
                if key == "PROGRESS":
                    try:
                        progress_acc += int(value)
                    except Exception:
                        pass
                elif key == "LOG":
                    # 收集日志消息，统一输出以减少重绘
                    log_msgs.append(value)
                elif key == "FILE_INIT":
                    file_inits.append(value)
                elif key == "FILE_PAGE":
                    try:
                        fid = str(value.get("id"))
                        name = self._file_names.get(fid, str(value.get("name", fid)))
                        cur = int(value.get("cur", 0))
                        total = int(value.get("total", 0) or self._file_totals.get(fid, 0))
                        if fid:
                            self._active_files[fid] = {"name": name, "cur": cur, "total": total}
                            updated_files.add(fid)
                    except Exception:
                        pass
                elif key == "FILE_ERROR":
                    file_errors.append(value)
                elif key == "FILE_DONE":
                    file_dones.append(value)
                elif key == "FILE_ABORT":
                    file_aborts.append(value)
                elif key == "RETRY_UPDATE":
                    retry_updates.append(value)
                elif key == "RETRY_DONE":
                    retry_dones.append(value)
                elif key == "LOADING_UPDATE":
                    load_update = value
                elif key == "LOADING_DONE":
                    load_done = True
                elif key == "LOADING_FAIL":
                    load_fails.append(value)
                elif key == "LOADING_OK":
                    load_oks.append(value)
                elif key == "FINISH":
                    finish_flag = True
                max_evts -= 1

            if progress_acc:
                total_progress = progress_acc + getattr(self, "_pending_progress", 0)
                apply_n = min(total_progress, getattr(self, "_progress_apply_limit", 8))
                self.progressed += apply_n
                self._pending_progress = total_progress - apply_n
                self.pb.configure(value=self.progressed)
            # 仅显示当前文件名，不汇总日志，提升性能
            # 更新加载窗口
            try:
                if load_update:
                    self._update_loading(int(load_update.get("idx", 0)), str(load_update.get("text", "")))
                if load_done:
                    self._hide_loading()
            except Exception:
                pass
            # 输出日志消息（批量写入，减少重绘）
            try:
                for msg in log_msgs:
                    self._log_print(str(msg))
            except Exception:
                pass
            # 汇总加载统计（完成时打印摘要与失败原因）
            try:
                if load_oks:
                    self._load_success += len(load_oks)
                    self._load_total += len(load_oks)
                if load_fails:
                    self._load_total += len(load_fails)
                    for v in load_fails:
                        name = str(v.get("name", ""))
                        reason = str(v.get("error", "未知错误"))
                        self._load_failures.append((name, reason))
                if load_done and not self._load_logged:
                    total = int(self._load_total)
                    self._log_print(f"加载完成：已加载 {total} 个")
                    for name, reason in self._load_failures[:10]:
                        self._log_print(f"- {name}：{reason}", "warn")
                    self._load_logged = True
            except Exception:
                pass
            # 处理文件初始化事件：记录文件名和总页数，仅更新标签和总页数
            try:
                if file_inits:
                    # 更新总页数与当前文件名
                    for v in file_inits:
                        fid = str(v.get("id"))
                        name = str(v.get("name", fid))
                        total = int(v.get("total", 0))
                        self._file_names[fid] = name
                        self._file_totals[fid] = total
                        if fid and (fid not in self._counted_file_ids):
                            self.total_pages += max(total, 1)
                            self._counted_file_ids.add(fid)
                    self.pb.configure(maximum=max(self.total_pages, 1))
                    # 将所有初始化的文件纳入并发状态集合，初始页数为 0
                    for v in file_inits:
                        fid = str(v.get("id"))
                        name = str(v.get("name", fid))
                        total = int(v.get("total", 0))
                        if fid:
                            self._active_files.setdefault(fid, {"name": name, "cur": 0, "total": total})
                    # 将新初始化文件分配到固定槽位（按第一个空槽），并刷新显示
                    try:
                        slot_count = ((len(getattr(self, "_slots", [])) or max(1, int(self.workers_var.get() or 1))) if bool(getattr(self, "running", False)) else 0)
                        if len(self._slots) != slot_count:
                            self._slots = [None] * slot_count
                        for v in file_inits:
                            fid_assign = str(v.get("id"))
                            if fid_assign and (fid_assign not in self._slots):
                                for i in range(slot_count):
                                    if self._slots[i] is None:
                                        self._slots[i] = fid_assign
                                        break
                        if getattr(self, "status_text", None):
                            items = []
                            try:
                                remaining = max(0, int(self.total_files) - (len(self._active_files) + int(self.files_done) + len(getattr(self, "aborted_files", set()))))
                            except Exception:
                                remaining = 0
                            for i in range(len(self._slots)):
                                fid_slot = self._slots[i]
                                if fid_slot:
                                    info = self._active_files.get(fid_slot, {})
                                    items.append(f"{info.get('name','')} {int(info.get('cur',0))}/{int(info.get('total',0))}")
                                else:
                                    if bool(getattr(self, "running", False)) and remaining > 0:
                                        items.append("等待分配")
                            plain = ("\n".join(items)) if items else ""
                            if plain != getattr(self, "_last_status_text", ""):
                                try:
                                    self.status_text.configure(state="normal")
                                    self.status_text.delete("1.0", "end")
                                    self.status_text.insert("end", "\n")
                                    for line in items:
                                        if line == "等待分配":
                                            self.status_text.insert("end", line + "\n", ("idle",))
                                        else:
                                            self.status_text.insert("end", line + "\n")
                                    self.status_text.configure(state="disabled")
                                    self._last_status_text = plain
                                except Exception:
                                    pass
                    except Exception:
                        pass
            except Exception:
                pass
            # 汇总并发处理状态，固定槽位顺序展示：文件名 cur/total
            try:
                if getattr(self, "status_text", None):
                    slot_count = ((len(getattr(self, "_slots", [])) or max(1, int(self.workers_var.get() or 1))) if bool(getattr(self, "running", False)) else 0)
                    if len(self._slots) != slot_count:
                        self._slots = [None] * slot_count
                    items = []
                    try:
                        remaining = max(0, int(self.total_files) - (len(self._active_files) + int(self.files_done) + len(getattr(self, "aborted_files", set()))))
                    except Exception:
                        remaining = 0
                    for i in range(len(self._slots)):
                        fid_slot = self._slots[i]
                        if fid_slot:
                            info = self._active_files.get(fid_slot, {})
                            items.append(f"{info.get('name','')} {int(info.get('cur',0))}/{int(info.get('total',0))}")
                        else:
                            if bool(getattr(self, "running", False)) and remaining > 0:
                                items.append("等待分配")
                    plain = ("\n".join(items)) if items else ""
                    if plain != getattr(self, "_last_status_text", ""):
                        try:
                            self.status_text.configure(state="normal")
                            self.status_text.delete("1.0", "end")
                            self.status_text.insert("end", "\n")
                            for line in items:
                                if line == "等待分配":
                                    self.status_text.insert("end", line + "\n", ("idle",))
                                else:
                                    self.status_text.insert("end", line + "\n")
                            self.status_text.configure(state="disabled")
                            self._last_status_text = plain
                        except Exception:
                            pass
            except Exception:
                pass
            for v in file_errors:
                try:
                    fid = str(v.get("id"))
                    page = int(v.get("page", 0))
                    error = str(v.get("error", "未知失败"))
                    # 将错误文本统一本地化为中文
                    err_text = localize_error_text(error)
                    if fid:
                        self.failed_pages.setdefault(fid, []).append((page, err_text))
                        # 同步输出失败页号与错误信息（中文）
                        try:
                            name = self._file_names.get(fid, fid)
                            if page <= 0:
                                self._log_print(f"- {name}：打开失败：{err_text}", "error")
                            else:
                                self._log_print(f"- {name} 页 {page}：{err_text}", "error")
                        except Exception:
                            pass
                except Exception:
                    pass
            for v in retry_updates:
                fid = v["id"]
                if not getattr(self, "_disable_right_list", False):
                    fr = self.file_rows.get(fid)
                    if fr:
                        fr["pb"].configure(value=v.get("cur", fr["cur"]))
                        fr["status"].configure(text=v.get("status", fr["status"].cget("text")))
            for v in retry_dones:
                fid = v["id"]
                errs_left = v.get("errors", [])
                if errs_left:
                    self.failed_pages[fid] = errs_left
                    if not getattr(self, "_disable_right_list", False):
                        fr = self.file_rows.get(fid)
                        if fr:
                            fr["err"].configure(text="\n".join([f"页 {p} 失败：{msg}" for p, msg in errs_left]))
                            self._move_row(fid, "failed")
                else:
                    self.failed_pages[fid] = []
                    self.files_done += 1
                    if not getattr(self, "_disable_right_list", False):
                        fr = self.file_rows.get(fid)
                        if fr:
                            fr["err"].configure(text="")
                            self._move_row(fid, "done")
            # 记录终止的文件ID
            for v in file_aborts:
                try:
                    fid = v.get("id") if isinstance(v, dict) else str(v)
                    self.aborted_files.add(fid)
                    # 终止后从并发集合移除
                    if fid and (fid in self._active_files):
                        self._active_files.pop(fid, None)
                    # 日志打印：文件终止加橙色“！”
                    try:
                        name = self._file_names.get(fid, "")
                        if not name:
                            try:
                                name = Path(fid).name
                            except Exception:
                                name = fid
                        self._log_print(f"终止：{name} ！", "warn")
                    except Exception:
                        pass
                    # 从槽位移除
                    try:
                        for i, s in enumerate(getattr(self, "_slots", [])):
                            if s == fid:
                                self._slots[i] = None
                    except Exception:
                        pass
                except Exception:
                    pass
            for v in file_dones:
                try:
                    fid = str(v.get("id"))
                    pages = int(v.get("pages", 0))
                    self.files_done += 1
                    # 完成后从并发集合移除，状态标签会在汇总时更新
                    if fid and (fid in self._active_files):
                        self._active_files.pop(fid, None)
                    # 日志打印：按是否有失败页选择红“×”或绿“√”
                    try:
                        name = self._file_names.get(fid, "")
                        if not name:
                            try:
                                name = Path(fid).name
                            except Exception:
                                name = fid
                        errs = self.failed_pages.get(fid, [])
                        if errs:
                            self._log_print(f"失败：{name} ×", "error")
                            # 失败列表摘要：统计失败页数与主要原因
                            try:
                                try:
                                    reasons = list({msg for p, msg in errs})
                                except Exception:
                                    reasons = [msg for p, msg in errs]
                                summary = "；".join(reasons[:3])
                                more = max(0, len(reasons) - 3)
                                suffix = f"（其余 {more} 种原因已省略…）" if more > 0 else ""
                                self._log_print(f"- {name}：{len(errs)} 页失败（原因：{summary}{suffix}）", "error")
                            except Exception:
                                pass
                            # 失败页汇总详情
                            try:
                                for p, msg in errs[:50]:  # 防止异常过多导致卡顿
                                    if p <= 0:
                                        self._log_print(f"  · 打开失败：{msg}", "error")
                                    else:
                                        self._log_print(f"  · 页 {p}：{msg}", "error")
                                if len(errs) > 50:
                                    self._log_print(f"  · 其余 {len(errs) - 50} 项已省略…", "warn")
                            except Exception:
                                pass
                        else:
                            self._log_print(f"完成：{name} √", "ok")
                    except Exception:
                        pass
                    # 从槽位移除
                    try:
                        for i, s in enumerate(getattr(self, "_slots", [])):
                            if s == fid:
                                self._slots[i] = None
                    except Exception:
                        pass
                except Exception:
                    pass
            # 更新文件进度与时间估算
            try:
                self.lbl_file_prog.configure(text=f"文件进度：{self.files_done}/{self.total_files}")
                if getattr(self, "start_time", 0):
                    end_ts = getattr(self, "end_time", None)
                    elapsed = int((end_ts if end_ts else time.time()) - self.start_time)
                else:
                    elapsed = 0
                rem_pages = max(self.total_pages - self.progressed, 0)
                eta = 0
                if self.progressed > 0 and rem_pages > 0 and not getattr(self, "end_time", None):
                    rate = elapsed / max(self.progressed, 1)
                    eta = int(rate * rem_pages)
                self.lbl_time.configure(text=f"用时 {self._fmt_time(elapsed)} | 预计 {self._fmt_time(eta)}")
            except Exception:
                pass
            # 主动完成检测：当无活动文件且所有文件都已完成或终止时，触发完成
            try:
                if not finish_flag and getattr(self, "running", False):
                    total = int(getattr(self, "total_files", 0) or 0)
                    done = int(getattr(self, "files_done", 0) or 0)
                    abort = len(getattr(self, "aborted_files", set()))
                    none_active = (len(getattr(self, "_active_files", {})) == 0)
                    if total > 0 and none_active and (done + abort) >= total:
                        finish_flag = True
            except Exception:
                pass
            if finish_flag:
                self.running = False
                # 记录结束时间以停止“用时”增长
                try:
                    self.end_time = time.time()
                except Exception:
                    self.end_time = getattr(self, "start_time", time.time())
                self._set_controls_running(False)
                try:
                    self.btn_start.configure(text="开始")
                    self.btn_start.state(["!disabled"]) 
                except Exception:
                    pass
                # 完成后清空并发槽位，避免显示“等待分配”
                try:
                    self._slots = []
                    self._active_files.clear()
                    self._last_status_text = ""
                except Exception:
                    pass
                self._log_print(f"全部完成。总页数: {self.progressed}。失败文件可在列表中点击重试。")
                # 汇总统计与彩色列表
                try:
                    total = self.total_files
                    failed_files = [fid for fid, errs in self.failed_pages.items() if errs]
                    fail = len(failed_files)
                    # 成功应为“已完成的文件数 - 失败文件数”，避免同一文件既计成功又计失败
                    succ = max(0, int(self.files_done) - int(fail))
                    abort = len(getattr(self, "aborted_files", set()))
                    self._log_print(f"总文件：{total} | 成功：{succ} | 失败：{fail} | 终止：{abort}", "stats")
                    if fail:
                        self._log_print("失败列表：", "error")
                        for fid in failed_files:
                            try:
                                name = Path(fid).name
                            except Exception:
                                name = fid
                            cnt = len(self.failed_pages.get(fid, []))
                            self._log_print(f"- {name}：{cnt} 页失败", "error")
                    if abort:
                        self._log_print("终止列表：", "warn")
                        for fid in sorted(getattr(self, "aborted_files", set())):
                            try:
                                name = Path(fid).name
                            except Exception:
                                name = fid
                            self._log_print(f"- {name}", "warn")
                except Exception:
                    pass
        except Exception:
            pass
        finally:
            try:
                self._poll_tick = getattr(self, "_poll_tick", 0) + 1
            except Exception:
                self._poll_tick = 0
            self.root.after(80, self._schedule_poll_events)

    # ---------- 开始/停止/退出 ----------
    def _on_start(self):
        if self.running:
            return
        # 清空状态（不再使用右侧列表）
        try:
            self.failed_pages.clear()
            self.aborted_files.clear()
            self._file_names.clear()
            self._file_totals.clear()
            self._counted_file_ids.clear()
            self._active_files.clear()
            self._last_status_text = ""
            # 重置加载统计
            self._load_total = 0
            self._load_success = 0
            self._load_failures = []
            self._load_logged = False
            if getattr(self, "status_text", None):
                try:
                    self.status_text.configure(state="normal")
                    self.status_text.delete("1.0", "end")
                    self.status_text.configure(state="disabled")
                except Exception:
                    pass
            # 初始化并行槽位，固定显示顺序
            try:
                slot_count = max(1, int(self.workers_var.get() or 1))
                self._slots = [None] * slot_count
            except Exception:
                self._slots = [None]
        except Exception:
            pass

        # 读取参数
        in_mode = self.input_mode_var.get()
        in_path = Path(self.input_path_var.get())
        # 输出目录：若不存在则尝试创建
        out_dir_str = (self.output_dir_var.get() or "").strip()
        if not out_dir_str:
            messagebox.showwarning("参数错误", "输出目录不能为空")
            return
        out_dir = Path(out_dir_str)
        try:
            ensure_dir(out_dir)
        except Exception as e:
            messagebox.showwarning("输出目录错误", f"无法创建输出目录：{e}")
            return
        try:
            dpi = int(self.dpi_var.get())
        except Exception:
            messagebox.showwarning("参数错误", "DPI必须为整数")
            return
        if dpi < 100 or dpi > 600:
            messagebox.showwarning("参数错误", "DPI范围为 100–600")
            return
        overwrite = bool(self.overwrite_var.get())
        # 并行参数：使用多进程，按输入与设置决定
        try:
            workers = int(self.workers_var.get() or 1)
        except Exception:
            workers = 1
        workers = max(1, min(workers, self.max_workers or workers))
        parallel = (workers > 1) and (len(self._file_names) == 0)  # 先假设并行，稍后根据pdfs长度修正
        
        # 校验路径
        if in_mode == "dir":
            if not in_path.exists() or not in_path.is_dir():
                messagebox.showwarning("路径错误", "输入文件夹不存在")
                return
            pdfs = collect_pdfs_from_dir(in_path)
        else:
            if not in_path.exists() or not in_path.is_file():
                messagebox.showwarning("路径错误", "输入文件不存在")
                return
            if in_path.suffix.lower() != ".pdf":
                messagebox.showwarning("路径错误", "请选择 PDF 文件")
                return
            pdfs = [in_path]
        
        # 根据文件数量最终确定是否并行
        parallel = parallel and (len(pdfs) > 1)
        # 为多进程停止信号做准备
        try:
            self.mp_stop_event = None
            self.mp_stop_flag = None
        except Exception:
            self.mp_stop_event = None
            self.mp_stop_flag = None

        if not pdfs:
            messagebox.showinfo("提示", "未在输入中找到 PDF 文件")
            return

        ensure_dir(out_dir)

        # 开始前提醒：支持“不再提醒”
        skip_warn = False
        try:
            skip_warn = bool(getattr(self, "settings", {}).get("skip_start_warning", False))
        except Exception:
            skip_warn = False
        proceed = True
        dont_remind = False
        if not skip_warn:
            proceed, dont_remind = self._ask_start_confirmation()
            if dont_remind:
                try:
                    self.settings = getattr(self, "settings", {}) or {}
                    self.settings["skip_start_warning"] = True
                    self._save_settings()
                except Exception:
                    pass
        if not proceed:
            self._log_print("用户取消转换")
            return

        # 开始后立即禁用路径与选项控件
        self._set_controls_running(True)

        # 初始化统计（不预先读取页数，避免导入过多时卡死）
        self.total_pages = 0
        self.progressed = 0
        self.total_files = len(pdfs)
        self.files_done = 0
        self.pb.configure(maximum=1, value=0)
        self._log_print(f"就绪：{len(pdfs)} 个PDF。开始转换…")

        # 启动后台线程
        self.running = True
        self.stop_event.clear()
        self.start_time = time.time()
        self.end_time = None
        self._set_controls_running(True)

        # 先展示加载窗口，逐个文件预加载，避免主界面卡死
        try:
            self._show_loading("正在加载文件…", total=len(pdfs))
        except Exception:
            pass

        # 并行策略：仅当“启用并行”且多文件时并行
        user_parallel = bool(self.parallel_var.get())
        if user_parallel and len(pdfs) > 1:
            parallel = True
            workers = max(1, min(int(self.workers_var.get()), self.max_workers or 2, len(pdfs)))
        else:
            parallel = False
            workers = 1
        # 根据实际有效并发，调整槽位数量，避免未分配的空槽
        try:
            self._slots = [None] * max(1, workers)
        except Exception:
            self._slots = [None]
        compression = (self.compression_var.get() or "lzw").lower()

        def run_convert():
            try:
                self._run_all(
                    pdfs, out_dir, dpi, overwrite, parallel, workers, compression,
                    int(self.unit_var.get() or 2),
                    bool(self.size_auto_var.get()),
                    str(self.size_mode_var.get() or "longest"),
                    int(self.long_side_var.get() or 0),
                    int(self.width_var.get() or 0),
                    int(self.height_var.get() or 0),
                )
            except Exception:
                log_exception_to_file()
                post_event("LOG", "运行异常，详情见日志文件。")
            finally:
                post_event("FINISH", True)

        def preload_worker():
            try:
                for idx, p in enumerate(pdfs, start=1):
                    try:
                        name = sanitize_filename(Path(p).stem)
                    except Exception:
                        name = os.path.basename(str(p))
                    # 更新加载提示
                    post_event("LOADING_UPDATE", {"idx": idx, "text": f"加载：{name}.pdf"})
                    # 读取总页数以便提前设置总进度
                    total = 0
                    try:
                        with fitz.open(p) as doc:
                            total = int(getattr(doc, "page_count", 0) or 0)
                            post_event("LOADING_OK", {"id": str(p), "name": f"{name}.pdf", "total": total})
                    except Exception as e:
                        total = 0
                        post_event("LOADING_FAIL", {"id": str(p), "name": f"{name}.pdf", "error": localize_error_text(str(e))})
                    # 预先添加文件行；真正转换时若再次发送相同ID会被界面去重
                    try:
                        post_event("FILE_INIT", {"id": str(p), "name": f"{name}.pdf", "total": total})
                    except Exception:
                        pass
                post_event("LOADING_DONE", True)
            finally:
                Thread(target=run_convert, daemon=True).start()

        Thread(target=preload_worker, daemon=True).start()



    def _lower_process_priority(self):
        try:
            if os.name == "nt":
                import ctypes
                PROCESS_SET_INFORMATION = 0x0200
                PROCESS_QUERY_INFORMATION = 0x0400
                BELOW_NORMAL_PRIORITY_CLASS = 0x00004000
                handle = ctypes.windll.kernel32.OpenProcess(
                    PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, False, os.getpid()
                )
                if handle:
                    ctypes.windll.kernel32.SetPriorityClass(handle, BELOW_NORMAL_PRIORITY_CLASS)
                    ctypes.windll.kernel32.CloseHandle(handle)
        except Exception:
            pass

    def _calc_page_sleep(self, dpi: int) -> float:
        try:
            if dpi >= 500:
                return 0.004
            if dpi >= 400:
                return 0.003
            if dpi >= 300:
                return 0.002
            if dpi >= 200:
                return 0.001
            return 0.0
        except Exception:
            return 0.0

    def _run_all(self, pdfs: List[Path], out_dir: Path, dpi: int, overwrite: bool, parallel: bool, workers: int, compression: str, unit: int, size_auto: bool, size_mode: str, long_side: int, width: int, height: int):
        try:
            if parallel and workers > 1:
                workers = max(1, min(workers, 20, self.max_workers or workers, len(pdfs)))
                # 多进程模式：不输出每文件日志，仅通过事件更新UI
                self._mp_manager = mp.Manager()
                self.mp_queue = self._mp_manager.Queue()
                self.mp_stop_flag = self._mp_manager.Value('b', False)
                # 启动事件转发线程
                self._mp_forwarder_stop = Event()
                def _forwarder():
                    q = getattr(self, "mp_queue", None)
                    if q is None:
                        return
                    while not self._mp_forwarder_stop.is_set():
                        try:
                            key, value = q.get(timeout=0.2)
                            try:
                                post_event(key, value)
                            except Exception:
                                pass
                        except Exception:
                            pass
                self._mp_forwarder_thread = Thread(target=_forwarder, daemon=True)
                self._mp_forwarder_thread.start()
                with ProcessPoolExecutor(max_workers=workers) as ex:
                    futures = []
                    for p in pdfs:
                        futures.append(ex.submit(
                            convert_pdf_worker,
                            str(p),
                            str(out_dir),
                            int(dpi),
                            bool(overwrite),
                            str(compression),
                            int(unit),
                            bool(size_auto),
                            str(size_mode),
                            int(long_side),
                            int(width),
                            int(height),
                            self.mp_queue,
                            getattr(self, "mp_stop_flag", None)
                        ))
                    for f in futures:
                        try:
                            f.result()
                        except Exception as e:
                            post_event("LOG", f"任务失败: {e}")
                # 停止转发线程
                try:
                    self._mp_forwarder_stop.set()
                    self._mp_forwarder_thread.join(timeout=1.5)
                except Exception:
                    pass
                # 关闭管理器
                try:
                    if getattr(self, "_mp_manager", None):
                        self._mp_manager.shutdown()
                except Exception:
                    pass
                self._mp_manager = None
                self.mp_queue = None
                self.mp_stop_flag = None
            else:
                # 顺序执行：仍通过事件更新UI
                for p in pdfs:
                    try:
                        self._convert_pdf(p, out_dir, dpi, overwrite, compression, unit, size_auto, size_mode, long_side, width, height)
                    except Exception as e:
                        post_event("LOG", f"任务失败: {e}")
        except Exception as e:
            post_event("LOG", f"执行失败: {e}")

    def _convert_pdf(self, pdf_path: Path, out_root: Path, dpi: int, overwrite: bool, compression: str, unit: int, size_auto: bool, size_mode: str, long_side: int, width: int, height: int):
        name = sanitize_filename(pdf_path.stem)
        out_dir = out_root / name
        ensure_dir(out_dir)
        # 若选择覆盖，则预先清理旧的 TIFF 文件，避免遗留页
        try:
            if overwrite:
                for p in out_dir.glob("*.tif"):
                    try:
                        p.unlink()
                    except Exception:
                        pass
        except Exception:
            pass
        cur = 0
        post_event("LOG", f"开始：{pdf_path} → {out_dir}")
        try:
            with fitz.open(pdf_path) as doc:
                total = doc.page_count
                post_event("FILE_INIT", {"id": str(pdf_path), "name": f"{name}.pdf", "total": total})
                for page_index in range(total):
                    if self.stop_event.is_set():
                        post_event("LOG", f"已停止：{pdf_path}")
                        try:
                            post_event("FILE_ABORT", {"id": str(pdf_path)})
                        except Exception:
                            pass
                        break
                    try:
                        ok = save_page_as_tif_retry(
                            doc,
                            page_index,
                            out_dir,
                            dpi,
                            overwrite,
                            compression=compression,
                            unit=unit,
                            adaptive_size=size_auto,
                            size_mode=size_mode,
                            target_long_side=long_side,
                            target_width=width,
                            target_height=height,
                        )
                        if ok:
                            cur += 1
                            post_event("PROGRESS", 1)
                            post_event("FILE_PAGE", {"id": str(pdf_path), "cur": cur, "total": total})
                            # 页面完成后给出极短让步，避免长时间100%占用导致UI掉帧
                            try:
                                s = self._calc_page_sleep(dpi)
                                if s > 0:
                                    time.sleep(s)
                            except Exception:
                                pass
                        else:
                            post_event("FILE_ERROR", {"id": str(pdf_path), "page": page_index + 1, "error": "未知失败"})
                    except Exception as e:
                        post_event("FILE_ERROR", {"id": str(pdf_path), "page": page_index + 1, "error": localize_error_text(str(e))})
        except Exception as e:
            post_event("LOG", f"打开失败：{pdf_path}，错误：{localize_error_text(str(e))}")
        finally:
            post_event("FILE_DONE", {"id": str(pdf_path)})

    def _retry_failed(self, file_id: str):
        pages = self.failed_pages.get(file_id) or []
        if not pages:
            messagebox.showinfo("重试", "该文件没有失败的页")
            return

        def do_retry():
            try:
                out_root = Path(self.output_dir_var.get())
                in_path = Path(file_id)
                name = sanitize_filename(in_path.stem)
                out_dir = out_root / name
                ensure_dir(out_dir)
                post_event("RETRY_UPDATE", {"id": file_id, "status": "正在重试失败页..."})
                errs_left = []
                with fitz.open(in_path) as doc:
                    for page, _msg in pages:
                        if self.stop_event.is_set():
                            break
                        try:
                            save_page_as_tif_retry(
                                doc,
                                page - 1,
                                out_dir,
                                int(self.dpi_var.get()),
                                bool(self.overwrite_var.get()),
                                compression=(self.compression_var.get() or "lzw").lower(),
                                unit=int(self.unit_var.get() or 2),
                                adaptive_size=bool(self.size_auto_var.get()),
                                size_mode=str(self.size_mode_var.get() or "longest"),
                                target_long_side=int(self.long_side_var.get() or 0),
                                target_width=int(self.width_var.get() or 0),
                                target_height=int(self.height_var.get() or 0),
                            )
                        except Exception as e:
                            errs_left.append((page, localize_error_text(str(e))))
                post_event("RETRY_DONE", {"id": file_id, "errors": errs_left})
            except Exception:
                log_exception_to_file()
                post_event("RETRY_DONE", {"id": file_id, "errors": pages})

        Thread(target=do_retry, daemon=True).start()

    def _on_stop(self):
        try:
            self.stop_event.set()
        except Exception:
            pass
        try:
            if getattr(self, "mp_stop_flag", None) is not None:
                self.mp_stop_flag.value = True
            elif getattr(self, "mp_stop_event", None):
                self.mp_stop_event.set()
        except Exception:
            pass
        self._log_print("停止信号已发送")
        # 主按钮保持“停止”可点击，等待后台完成

    def _open_output(self):
        try:
            out_dir = self.output_dir_var.get()
            if out_dir:
                os.startfile(out_dir)
        except Exception:
            pass

    def _on_window_close(self):
        try:
            if getattr(self, "running", False):
                ok = messagebox.askokcancel("退出确认", "当前有任务在运行，退出将停止任务并关闭程序。是否退出？")
            else:
                ok = messagebox.askokcancel("退出确认", "是否退出程序？")
            if ok:
                self._on_exit()
        except Exception:
            try:
                self.root.destroy()
            except Exception:
                pass

    def _on_exit(self):
        try:
            self._on_stop()
        except Exception:
            pass
        # 额外清理：停止事件转发线程与关闭 Manager，避免残留子进程
        try:
            if getattr(self, "_mp_forwarder_stop", None):
                try:
                    self._mp_forwarder_stop.set()
                except Exception:
                    pass
            th = getattr(self, "_mp_forwarder_thread", None)
            if th:
                try:
                    th.join(timeout=1.0)
                except Exception:
                    pass
        except Exception:
            pass
        try:
            mgr = getattr(self, "_mp_manager", None)
            if mgr:
                try:
                    mgr.shutdown()
                except Exception:
                    pass
        except Exception:
            pass
        try:
            self._mp_manager = None
            self.mp_queue = None
            self.mp_stop_flag = None
        except Exception:
            pass
        # 兜底：强制终止仍存活的子进程（如并行池进程、Manager 服务进程）
        try:
            for proc in mp.active_children():
                try:
                    proc.terminate()
                except Exception:
                    pass
                try:
                    proc.join(timeout=2.0)
                except Exception:
                    pass
        except Exception:
            pass
        try:
            self.root.destroy()
        except Exception:
            pass


def gui_main():
    try:
        root = tk.Tk()
        # 先显示启动进度条（Splash），加载完成再进入主界面
        root.withdraw()
        splash = tk.Toplevel(root)
        splash.overrideredirect(True)
        splash_title = ttk.Label(splash, text="PDF 转 TIFF 正在加载…", font=("Microsoft YaHei", 11))
        splash_title.pack(padx=20, pady=(16, 6))
        splash_sub = ttk.Label(splash, text="启动中，请稍候", foreground="gray")
        splash_sub.pack(padx=20)
        pb = ttk.Progressbar(splash, mode="indeterminate", length=260)
        pb.pack(padx=20, pady=(10, 16))

        def _center(win: tk.Toplevel):
            win.update_idletasks()
            sw = win.winfo_screenwidth()
            sh = win.winfo_screenheight()
            w = 320
            h = 100
            x = int((sw - w) / 2)
            y = int((sh - h) / 2)
            win.geometry(f"{w}x{h}+{x}+{y}")

        _center(splash)
        pb.start(12)

        def _finish_startup():
            try:
                # 若有预加载动作，可在此进行（当前略过）
                pass
            finally:
                pb.stop()
                splash.destroy()
                root.deiconify()
                PdfToTifApp(root)

        # 给一点视觉反馈时间，避免一闪而过（约 700ms）
        root.after(700, _finish_startup)
        root.mainloop()
    except Exception:
        log_exception_to_file()
        popup_error_fallback("错误", "程序遇到未处理异常，已记录日志。")


def _fmt_time(self, secs: int) -> str:
    try:
        secs = int(max(secs, 0))
        h = secs // 3600
        m = (secs % 3600) // 60
        s = secs % 60
        if h:
            return f"{h:02d}:{m:02d}:{s:02d}"
        else:
            return f"{m:02d}:{s:02d}"
    except Exception:
        return "00:00"


def _show_loading(self, title: str, total: int):
    try:
        self.loading_win = tk.Toplevel(self.root)
        self.loading_win.overrideredirect(True)
        self.loading_win.attributes("-topmost", True)
        # 不显示加载文字，仅显示进度条
        self.loading_msg = None
        self.loading_pb = ttk.Progressbar(self.loading_win, mode="determinate", maximum=max(total, 1), length=280)
        self.loading_pb.pack(padx=20, pady=(12, 12))
        self.loading_win.update_idletasks()
        sw = self.loading_win.winfo_screenwidth()
        sh = self.loading_win.winfo_screenheight()
        w, h = 320, 90
        x = int((sw - w) / 2)
        y = int((sh - h) / 2)
        self.loading_win.geometry(f"{w}x{h}+{x}+{y}")
    except Exception:
        pass


def _update_loading(self, idx: int, message: Optional[str] = None):
    try:
        if getattr(self, "loading_msg", None) and message:
            self.loading_msg.configure(text=message)
        if getattr(self, "loading_pb", None):
            self.loading_pb.configure(value=idx)
        self.root.update_idletasks()
    except Exception:
        pass


def _hide_loading(self):
    try:
        if getattr(self, "loading_win", None):
            self.loading_win.grab_release()
            self.loading_win.destroy()
        self.loading_win = None
        self.loading_pb = None
        self.loading_msg = None
    except Exception:
        pass

# 绑定为类方法，确保在主界面使用前可用
PdfToTifApp._fmt_time = _fmt_time
PdfToTifApp._show_loading = _show_loading
PdfToTifApp._update_loading = _update_loading
PdfToTifApp._hide_loading = _hide_loading

if __name__ == "__main__":
    try:
        import multiprocessing as mp
        mp.freeze_support()
        try:
            mp.set_start_method("spawn")
        except RuntimeError:
            pass
    except Exception:
        pass
    gui_main()